在日常生活中,大家應該滿常看到有些系統的搜尋輸入框是可以在一邊打字的同時,一邊將搜尋結果呈現在一個下拉選單裡,非常地貼心且方便。
當然,這其中其實有很多細節,不過我們今天就專注在前端的表單開發上,來用 Reactive Forms 實作這個搜尋輸入框吧!
沒錯,就算只是個搜尋框,它也是個表單噢!
正好最近六角學院即將舉辦第三屆的前端 & UI 修煉精神時光屋的活動,這次它們與交通部合作,並提供了全國最大的
運輸資料流通服務平台 (TDX) 之交通 API 給大家使用,讓大家可以透過此活動精進自己的實力,非常推薦給大家。
想當初我第一次寫鐵人賽時,也是使用了參加六角舉辦的第一屆前端修煉精神時光屋的素材來寫,雖然這次沒有要參賽,但又跟六角有關係了呢!
總之,藉由這次的機會與交通部提供的 運輸資料流通服務平台 (TDX) 之交通 API ,我們來簡單地做一個可以查詢台北捷運的車站的搜尋輸入框吧!
這次因為有 API 可以使用的關係,會精實很多,如果跟不上的朋友,可能要再多熟悉一下 Angular 噢!
簡單來說,這個功能會需要一個輸入框與一個表格,當使用者在輸入框裡打字時,表格的內容也會連動呈現出搜尋結果。
由於 Auto-Complete 的搜尋輸入框如果要自己做會需要處理不少細節,又不想安裝 UI 框架佔篇幅,所以我用這個方式來呈現查詢結果。
表格的欄位有以下這些:
最後呈現結果:
首先,如果在需求明確的情況下,我個人習慣會先把畫面準備好。
HTML 的部份大概會長這樣:
<p><input type="text" placeholder="請輸入捷運站名稱" /></p>
<table>
<caption>
台北捷運之捷運站查詢結果
</caption>
<thead>
<tr>
<td>車站代號</td>
<td>車站名稱</td>
<td>車站所屬縣市</td>
<td>車站所屬鄉鎮區</td>
<td>假日是否允許自行車進出站</td>
<td>位置</td>
</tr>
</thead>
<tbody>
<tr>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
<td>
<a target="_blank" href=""></a>
</td>
</tr>
</tbody>
</table>
CSS 的部份大家就自行發揮囉!
畫面看起來會像這樣:
接著我們會需要一個 FormControl
來跟輸入框綁定,所以我們在 .ts
裡新增一個屬性 ─ searchingInputControl
:
export class ReactiveFormsAutoCompleteSearchingComponent implements OnInit {
searchingInputControl = new FormControl();
}
別忘了先到
.module.ts
裡引入FormsModule
與ReactiveFormsModule
噢!
然後將 searchingInputControl
與畫面輸入框綁定:
<p><input type="text" placeholder="請輸入捷運站名稱" [formControl]="searchingInputControl" /></p>
接著我們使用昨天分享過的 valueChanges
來確認是否已正確綁定:
export class ReactiveFormsAutoCompleteSearchingComponent implements OnInit {
searchingInputControl = new FormControl();
ngOnInit(): void {
this.searchingInputControl.valueChanges.subscribe((value) => {
console.log(value);
});
}
}
結果:
看起來已經有正確的跟搜尋輸入框綁定了,那接下來要怎麼做才好呢?
我們的目的是希望使用者在輸入捷運站名稱的同時,只留下跟使用者的輸入有關聯的捷運站。
因此,我們會需要一支 Service 來幫我們呼叫交通部所提供的 運輸資料流通服務平台 (TDX) 之交通 API ,並把查詢結果顯示到畫面上。
Service 的程式碼大概會長這個樣子:
@Injectable()
export class ReactiveFormsAutoCompleteSearchingService {
constructor(private httpClient: HttpClient) { }
searchStation(stationName: string): Observable<MetroStationDTO[]> {
let url = 'https://ptx.transportdata.tw/MOTC/v2/Rail/Metro/Station/TRTC?$format=JSON';
if (stationName) {
url += `&$filter=contains(StationName/Zh_tw,'${stationName}')`;
}
return this.httpClient.get<MetroStationDTO[]>(url);
}
}
上述程式碼中有以下幾個重點:
要呼叫 API 的話,需要先到 .module.ts
裡引入 HttpClientModule
,才能在 Service 裡使用 HttpClient
來呼叫 API。
MetroStationDTO
是我根據交通部所提供的 運輸資料流通服務平台 (TDX) 之交通 API 裡定義的資料介面,詳細位置需先選擇「軌道」再點選「捷運」,如下圖所示:
由於 HTTP Method 是 GET
的緣故,所以參數是使用 Query Parameters
的方式帶進 URL 之中。
如果使用者沒有輸入站名時,還帶 $filter
參數會收到伺服器回傳的 Bed Request
錯誤,因此增加一個判斷式 ─ 當傳入的 stationName
為 Truthy 值時,才帶 $filter
參數。
參數 $filter
的值該怎麼帶這件事情其實在文件中沒有寫,算是這個文件比較美中不足的地方。好在六角學院的院長 ─ 廖洧杰院長前陣子有開直播課教學,而我猜測院長一定有在那堂課講這件事情,所以去翻了一下該堂直播課的共筆才找到該怎麼帶它的值。
Service 準備好之後,接下來就要將 FormControl
的 valueChanges
事件與 API 相結合了。
準備好
見證神蹟了嗎?
RxJS 真的是一個很棒的函式庫,它讓我們可以很好地操作非同步與資料串流,而且還能讓我們的程式碼非常地簡潔、非常地好閱讀。
就像我們現在需要把使用者的輸入事件與 API 做結合時,用 RxJS 的 Operators 就可以非常完美、漂亮地結合在一起。
就像這樣:
export class ReactiveFormsAutoCompleteSearchingComponent implements OnInit {
searchingInputControl = new FormControl();
constructor(private service: ReactiveFormsAutoCompleteSearchingService) { }
ngOnInit(): void {
this.searchingInputControl.valueChanges.pipe(
startWith(''),
debounceTime(500),
switchMap(value => this.service.searchStation(value))
).subscribe((result) => {
console.log(result);
});
}
}
結果:
我相信在這邊一定會有非常多朋友看傻眼,這是什麼神操作?!這樣就接好了?!
沒錯!這樣就接好了,是不是比你想像中簡單非常多呢?
那這串到底做了什麼事呢?
首先,我希望這個畫面一開始的時候就會先查詢一次,所以我使用 startWith('')
來呼叫查詢 API 。
再者,我希望查詢的間隔不要太過快速,當使用者「可能」已經打完字的時候才查詢,所以我使用 debounceTime(500)
來讓查詢的時間點會在使用者停止打字 500 毫秒後才呼叫查詢 API。
最後,則要將原本是 valueChanges
的 Observable 轉換成 呼叫 API 的 Observable 這件事情 ,所以我使用 switchMap(value => this.service.searchStation(value))
。
關於
startWith
,大家可以參考官方文件或是 Mike 的文章。
接著,我們要將得到的資料綁定到畫面上,而綁定到畫面上的方式大致上有兩種:
export class ReactiveFormsAutoCompleteSearchingComponent implements OnInit {
searchingInputControl = new FormControl();
stations: MetroStationDTO[] = [];
constructor(private service: ReactiveFormsAutoCompleteSearchingService) { }
ngOnInit(): void {
this.searchingInputControl.valueChanges.pipe(
startWith(''),
debounceTime(500),
switchMap(value => this.service.searchStation(value))
).subscribe((stations) => {
this.stations = stations;
});
}
}
然後再綁到畫面上:
<table>
<caption>
台北捷運之捷運站查詢結果
</caption>
<thead>
<tr>
<td>車站代號</td>
<td>車站名稱</td>
<td>車站所屬縣市</td>
<td>車站所屬鄉鎮區</td>
<td>假日是否允許自行車進出站</td>
<td>位置</td>
</tr>
</thead>
<tbody>
<tr *ngFor="let station of stations">
<td>{{ station.StationID }}</td>
<td>{{ station.StationName.Zh_tw }}</td>
<td>{{ station.LocationCity }}</td>
<td>{{ station.LocationTown }}</td>
<td>{{ station.BikeAllowOnHoliday }}</td>
<td>
<a target="_blank" [href]="station.StationPosition">
{{ station.StationPosition }}
</a>
</td>
</tr>
</tbody>
</table>
export class ReactiveFormsAutoCompleteSearchingComponent {
searchingInputControl = new FormControl();
stations$ = this.searchingInputControl.valueChanges.pipe(
startWith(''),
debounceTime(500),
switchMap(value => this.service.searchStation(value))
);
constructor(private service: ReactiveFormsAutoCompleteSearchingService) { }
}
然後透過 AsyncPipe
讓 Template 自己訂閱:
<table>
<caption>
台北捷運之捷運站查詢結果
</caption>
<thead>
<tr>
<td>車站代號</td>
<td>車站名稱</td>
<td>車站所屬縣市</td>
<td>車站所屬鄉鎮區</td>
<td>假日是否允許自行車進出站</td>
<td>位置</td>
</tr>
</thead>
<tbody>
<tr *ngFor="let station of (stations$ | async) || []">
<td>{{ station.StationID }}</td>
<td>{{ station.StationName.Zh_tw }}</td>
<td>{{ station.LocationCity }}</td>
<td>{{ station.LocationTown }}</td>
<td>{{ station.BikeAllowOnHoliday }}</td>
<td>
<a target="_blank" [href]="station.StationPosition">
{{ station.StationPosition }}
</a>
</td>
</tr>
</tbody>
</table>
就結果來說,這兩個方法基本上都可以,但我個人非常推薦使用第二種方式。
原因是使用第二種的方式一方面可以避免我們在 Component 被 Destroy 時忘記解除訂閱而導致 Memory Leak 的情形,另一方面是 Observable 會比單純資料好用很多。
甚至有時候我們自己訂閱會發生「明明資料就有收到但畫面沒有更新」的詭異狀況。
結果:
雖然目前運作良好,但還有一些小東西還沒處理完:
是
或是 否
。latitude, longitude
的格式呈現。以上這三個小東西非常地簡單,我想大家應該也都知道該怎麼做,但是既然都已經到了第二十四天了,這邊我覺得我們要使用 Pipe
,而不是像之前一樣直接寫在 Component 裡。
這是因為,如果像之前的 getErrorMessage
是寫在 Component 裡的話,其實當畫面渲染時,該函式就會被呼叫,不管該值有沒有被改變。
但是使用 Pipe
的話,在該值被改變前,是不會被呼叫第二次的。
再者,使用 Pipe
的話,重用性與可維護性也比較好。
所以我建議大家可以使用 Pipe
來完成最後的小調整。
我個人會建立三個 Pipe
─ BooleanInZhTwPipe
、 GoogleMapLinkPipe
與 LocationStringPipe
。
它們的程式碼如下:
@Pipe({
name: 'booleanInZhTw'
})
export class BooleanInZhTwPipe implements PipeTransform {
transform(value: boolean, ...args: unknown[]): string {
return value ? '是' : '否';
}
}
@Pipe({
name: 'googleMapLink'
})
export class GoogleMapLinkPipe implements PipeTransform {
transform({ PositionLat, PositionLon }: StationPosition, ...args: unknown[]): string {
return `https://www.google.com/maps?q=${PositionLat},${PositionLon}&z=7`;
}
}
@Pipe({
name: 'locationString'
})
export class LocationStringPipe implements PipeTransform {
transform({ PositionLat, PositionLon }: StationPosition, ...args: unknown[]): string {
return `${PositionLat}, ${PositionLon}`;
}
}
最終結果:
今天的重點主要是:
startWith
、 debounceTime
與 switchMap
將 valueChanges
與呼叫 API 串聯。Pipe
。今天的練習對於一些剛學 Angular 的朋友來說會滿精實且資訊量有點大的,大家可以多看幾遍,多自己練習、做實驗,相信對大家來說會很有幫助。
關於 RxJS ,如果大家想知道更多資訊,我推薦大家去看 Mike 的打通 RxJS 任督二脈系列文,或者是直接買實體書也行。
雖然今天的實作已經完成了,但還有測試的部份,我們明天來撰寫它吧!
今天的程式碼會放在 Github - Branch: day24 上供大家參考,建議大家在看我的實作之前,先按照需求規格自己做一遍,之後再跟我的對照,看看自己的實作跟我的實作不同的地方在哪裡、有什麼好處與壞處,如此反覆咀嚼消化後,我相信你一定可以進步地非常快!
如果有任何的問題或是回饋,還請麻煩留言給我讓我知道!
RxJS真的太神奇啦(歡呼)
之前也是看了Mike大的文章+書了解許多,大大這篇結合許多應用,又學到更多了。
Hi heerupche
很高興此文章能夠幫到你^^
Hi Leo,
好奇問個簡單的問題,通常AsyncPipe
的變數尾巴都會加上$
的符號嗎?
慣例上我們會在變數尾巴加個 $
以便於知道該變數是 Observable
~
了解~之後尾巴看到$就會知道是Observable了XD
是的沒錯~